admin / Strike
publicWeb-Based UK Cyber Compliance Tool with Reporting
Strike / StrikeXi v3 / backend / app / scoring.py
5374 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | """ StrikeXi scoring engine. Computes weighted maturity scores from an assessment's answers: - Per-question score = option.score (0-1) * question.weight - Per-principle score = sum(question scores) / sum(weights) -> 0-1 - Per-objective score = weighted mean of its principles -> 0-100 - Overall score = mean of objective scores -> 0-100 Then it evaluates remediation_mappings: any principle whose normalised score falls below the mapping threshold queues the linked remediation. """ from collections import defaultdict from sqlalchemy.orm import Session from . import models def compute_and_persist(db: Session, assessment: models.Assessment, triggered_by: str = "system", label: str = None) -> dict: # Pull answers joined to question + option answers = ( db.query(models.AssessmentAnswer) .filter(models.AssessmentAnswer.assessment_id == assessment.id) .all() ) q_by_id = {q.id: q for q in db.query(models.Question).all()} opt_by_id = {o.id: o for o in db.query(models.AnswerOption).all()} principles = {p.id: p for p in db.query(models.CafPrinciple).all()} objectives = {o.id: o for o in db.query(models.CafObjective).all()} # Accumulate weighted scores per principle pr_weighted = defaultdict(float) # sum(score*weight) pr_weight = defaultdict(float) # sum(weight) for a in answers: q = q_by_id.get(a.question_id) opt = opt_by_id.get(a.option_id) if not q or not opt: continue w = float(q.weight) pr_weighted[q.principle_id] += float(opt.score) * w pr_weight[q.principle_id] += w principle_scores = {} # principle_id -> normalised 0-1 for pid in pr_weight: principle_scores[pid] = pr_weighted[pid] / pr_weight[pid] if pr_weight[pid] else 0.0 # Objective scores = mean of contained principle scores (0-100) obj_principles = defaultdict(list) for pid, score in principle_scores.items(): pr = principles.get(pid) if pr: obj_principles[pr.objective_id].append(score) objective_scores = {} for oid, scores in obj_principles.items(): objective_scores[oid] = round((sum(scores) / len(scores)) * 100, 2) overall = round(sum(objective_scores.values()) / len(objective_scores), 2) if objective_scores else 0.0 # ---- Persist per-principle scores (replace existing) ---- db.query(models.AssessmentPrincipleScore).filter( models.AssessmentPrincipleScore.assessment_id == assessment.id ).delete() for pid, score in principle_scores.items(): db.add(models.AssessmentPrincipleScore( assessment_id=assessment.id, principle_id=pid, score=round(score * 100, 2) )) # ---- Persist objective scores (replace existing) ---- db.query(models.AssessmentObjectiveScore).filter( models.AssessmentObjectiveScore.assessment_id == assessment.id ).delete() for oid, score in objective_scores.items(): db.add(models.AssessmentObjectiveScore( assessment_id=assessment.id, objective_id=oid, score=score )) # ---- Evaluate remediation mappings ---- db.query(models.RemediationAction).filter( models.RemediationAction.assessment_id == assessment.id ).delete() mappings = db.query(models.RemediationMapping).all() triggered = 0 for m in mappings: pscore = principle_scores.get(m.principle_id) if pscore is None: continue if pscore < float(m.threshold): db.add(models.RemediationAction( assessment_id=assessment.id, principle_id=m.principle_id, remediation_id=m.remediation_id, principle_score=round(pscore * 100, 2), status="queued", )) triggered += 1 assessment.overall_score = overall assessment.status = "completed" principle_scores_100 = {k: round(v * 100, 2) for k, v in principle_scores.items()} # ---- Snapshot an immutable revision (v3) ---- # Revision 1 = first completion; each re-score appends a new revision so the # ORIGINAL scores/answers are always retained for the audit trail and report. prev_no = ( db.query(models.AssessmentRevision) .filter(models.AssessmentRevision.assessment_id == assessment.id) .count() ) revision_no = prev_no + 1 snapshot = { "overall_score": overall, "objective_scores": objective_scores, "principle_scores": principle_scores_100, "answers": {str(a.question_id): str(a.option_id) for a in answers}, "answer_count": len(answers), "remediations_triggered": triggered, } db.add(models.AssessmentRevision( assessment_id=assessment.id, revision_no=revision_no, label=label or ("Initial assessment" if revision_no == 1 else f"Re-score {revision_no - 1}"), overall_score=overall, snapshot=snapshot, triggered_by=triggered_by, )) db.commit() return { "overall_score": overall, "objective_scores": objective_scores, "principle_scores": principle_scores_100, "remediations_triggered": triggered, "revision_no": revision_no, "is_rescore": revision_no > 1, } |